<?php  if ( ! defined('BASEPATH')) exit('No direct script access allowed');
/**************************************************************************
 *
 *   Copyright 2010 American Public Media Group
 *
 *   This file is part of AIR2.
 *
 *   AIR2 is free software: you can redistribute it and/or modify
 *   it under the terms of the GNU General Public License as published by
 *   the Free Software Foundation, either version 3 of the License, or
 *   (at your option) any later version.
 *
 *   AIR2 is distributed in the hope that it will be useful,
 *   but WITHOUT ANY WARRANTY; without even the implied warranty of
 *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *   GNU General Public License for more details.
 *
 *   You should have received a copy of the GNU General Public License
 *   along with AIR2.  If not, see <http://www.gnu.org/licenses/>.
 *
 *************************************************************************/


/***************************************************************************
 * AIR2_CRUD_Controller:
 *
 * This CodeIgniter controller was developed as a base class for RESTful
 * resources exposed via the AIR2 API.  It encapsulates CRUD actions, tagging,
 * metadata, paging, searching and exposing related components, to name a few.
 *
 *
 * --- Creation ---
 * If you have a model called 'Foo' that you want to expose via the API, first
 * edit app/config/routes.php and add 'foo' to the list of $crud_controllers.
 * This indicates that the resource will be exposed at the url 'air2/foo'.
 *
 * Next create your controller (app/controllers/foo_controller.php) and extend
 * AIR2_CRUD_Controller.  In addition, you will need to define (override)
 * several variables from the base class.
 *      class Foo_Controller extends AIR2_CRUD_Controller {
 *          public $base_model_name = 'Project';
 *          public $base_view_name  = 'project';
 *          public $exposed_relations = array();
 *          public $autoload_relations = array();
 *          public $column_blacklist = array();
 *          public $column_whitelist = array();
 *          public $taggable = true;
 *      }
 *
 * Some notes about the variables:
 *      $base_model_name : the name of the doctrine model at the base of this
 *          class.
 *
 *      $base_view_name : the uri of this view (as defined in routes.php)
 *
 *      $exposed_relations : associative array of exposed URI's to the alias of
 *          their relation to the base model.  For example, we might see this
 *          in the foo_controller (exposes air2/foo/phone/):
 *              $exposed_relations = array('phone' => 'FooPhoneNumbers');
 *
 *          To expose an object that isn't directly related to the base model,
 *          just put the name of the model in place of the relation alias.  Ex:
 *              $exposed_relations = array('friend' => 'FooFriend');
 *
 *           Here, there is no doctrine relation Foo->FooFriend.  It will be up
 *          to us to restrict the results of querying FooFriend to those
 *          actually related to Foo.
 *
 *          Finally, to expose a uri that is HTML-only (no data loaded), set
 *          the relation alias to null.  (Non html requests to this url will
 *          return 404).
 *              $exposed_relations = array('details' => null);
 *
 *      $autoload_relations : list of uri-relations that will automatically be
 *          joined by queries we produce, if the relation exists.  Ex:
 *              $autoload_relations = array('phone', 'friend', 'something');
 *
 *      $column_blacklist : list of column names that will be excluded from
 *          any output.
 *
 *      $column_whitelist : list of column names that will be included in the
 *          output, even if something else tries to exclude them.  For example,
 *          columns ending with "_id" are normally removed from output, but we
 *          can add them back in with this list.
 *
 *      $taggable : indicates if this base model is "taggable".  This will both
 *          expose the "air2/foo/tags" uri, and automate adding/removing tags.
 *          TODO: comments on making a model taggable
 *
 *      $unique_name_col : indicates that this resource has some sort of unique
 *          naming scheme.  This makes the URL "/air2/foo/name" accessible by
 *          GET, and allows searching the name col with the "q" parameter.
 *
 * --- Other important class variables ---
 *      $this->base_uuid : (read-only, allows null) during execution, the uuid
 *          of the base model.
 *      $this->rel_uri : (read-only, allows null) during execution, the name of
 *          the related uri.  (As found in $exposed_relations)
 *      $this->rel_uuid : (read-only, allows null) during execution, the uuid
 *          of the related model.
 *      $this->success : Filled in by us to return true/false success status
 *      $this->message : Optionally filled in by us to return a message
 *      $this->extra_field_defs : list of field definitions to add to metadata.
 *          Will include fields that aren't visible in the AIR2_Query, such
 *          as composite and custom select statements.  (see _build_tag_query
 *          for an example).
 *
 *
 * --- Overriding Methods ---
 * Here are some examples of methods you may commonly want to override in
 * subclasses of AIR2_CRUD_Controller.  For example, here is what it might look
 * like for foo_controller.php:
 *
 *      // this function allows us to alter the query prior to execution,
 *      // normally to add related items or set default sorting.
 *      // IMPORTANT: Base models always have the alias "a"
 *      protected function _add_query_load_related(&$q, $deeply=true) {
 *          if (!$this->rel_uri) { // base level
 *              $q->leftJoin('a.FooPhoneNumbers');
 *          }
 *          elseif ($this->rel_uri === 'phone') {
 *              $q->leftJoin('a.PhoneNumberCreator');
 *          }
 *          return parent::_add_query_load_related($q, $deeply);
 *      }
 *
 *      // this function gets called with data returned by the url we are at,
 *      // and is overridden to alter that data and/or change the view rendered
 *      // as the body of the response.
 *      public function get_html_body($data, $view_name=null) {
 *          if ($this->base_uuid && !$this->rel_uri) { // base level (non-list)
 *              $view_name = 'foo_dashboard';
 *          }
 *          elseif ($this->rel_uri === 'phone') { //phone html view
 *              $data = $this->_html_phone_alter_data($data);
 *              $view_name = 'foo_phone';
 *          }
 *          return parent::get_html_body($data, $view_name);
 *      }
 *
 *
 * --- Typical File Setup ---
 *   app/controllers/foo_controller.php
 *          Controller
 *   app/views/crud/foo.php
 *          Default view - (same name as $base_view_name) - normally javascript
 *   public_html/js/foo.js
 *          Default javascript file to include
 *   public_html/css/foo.css
 *          Default css file to include
 *   app/config/routes.php
 *          Altered to add 'foo' to $crud_controllers
 *
 */

// ------------------------------------------------------------------------

/**
 * AIR2 CRUD Controller Base Class
 *
 * Implements basic REST-style CRUD actions and other features common to
 * all resources in the AIR2 API.
 *
 * @version 2
 * @author rcavis
 * @package default
 */
class AIR2_CRUD_Controller extends AIR2_Controller {
    /* Configuration to be set by subclasses */
    public $base_model_name;
    public $base_view_name;
    public $exposed_relations = array();
    public $autoload_relations = array();
    public $column_blacklist = array();
    public $column_whitelist = array();
    public $taggable = false; //optional
    public $unique_name_col = false; //optional
    public $flatten_mapping = array();

    /* generic response variables, set by subclass at runtime */
    public $success = true;
    public $message = '';

    /* protected runtime variables, set by this class only */
    protected $base_uuid;
    protected $rel_uri;
    protected $rel_uuid;
    protected $base_record_cache;
    protected $extra_field_defs = array();
    protected $cached_put; //cache PUT parameters

    /* recent html views to track */
    protected $global_blacklist = array('user_pref'); // never show this column
    protected $recent_views = array('project', 'source', 'inquiry', 'submission');
    protected $recent_max = 5;


    /**
     * Constructor items, called after AIR2_Controllers constructor.  Security
     * is run by the parent.
     */
    public function begin() {
        parent::begin();
        $this->load->library('AirQauthz', array('authz_obj' => $this->airuser->get_authz()));
        if ($this->taggable) $this->load->library('AirTag');
    }


    //****************************************************************************//
    //  FUNCTIONS EXPOSED THROUGH THE CODEIGNITER INTERFACE                       //
    //  (Called based on app/config/routes.php)                                   //
    //  These all return by echoing their output (crud_response() or response())  //
    //****************************************************************************//
    /**
     * GET request for a single resource
     *
     * @return radix (single) and metaData
     * @param string  $uuid identifier of base model
     */
    public function crud_GET_one($uuid) {
        // check for "name" as uuid --- indicates this is a name search
        if ($this->unique_name_col && $uuid == 'name') {
            $q = $this->get_name_query();
            $res = $q->fetchArray();
            $data = array('radix' => array(), 'total' => $q->count());
            foreach ($res as $rec) {
                $data['radix'][] = $rec[$this->unique_name_col];
            }
            $this->crud_response($data);
        }
        else {
            // do a GET single
            $this->base_uuid = $uuid;
            $q = $this->build_query($uuid);
            $data = $this->fetch_data($q);
            $this->crud_response($data);
        }
    }


    /**
     * Gets a query to find a list of unique names for resources
     *
     * @return AIR2_Query to list names
     */
    protected function get_name_query() {
        $q = AIR2_Query::create();
        $q->select($this->unique_name_col);
        $q->from($this->base_model_name);
        if (isset($this->query_args['q'])) {
            $q->where($this->unique_name_col.' = ?', $this->query_args['q']);
        }
        return $q;
    }


    /**
     * GET request for a single related resource
     *
     * @return radix (single) and metaData
     * @param string  $uuid     identifier of base model
     * @param string  $rel_uri  uri of related model
     * @param string  $rel_uuid identifier of related model
     */
    public function crud_GET_one_related($uuid, $rel_uri, $rel_uuid) {
        $this->base_uuid = $uuid;
        $this->rel_uri = $rel_uri;
        $this->rel_uuid = $rel_uuid;

        $q = $this->build_query($uuid, $rel_uri, $rel_uuid);
        $data = $this->fetch_data($q);
        $this->crud_response($data);
    }


    /**
     * GET request for list of multiple resources
     *
     * @return radix (list) and metaData
     */
    public function crud_GET_list() {
        $q = $this->build_query(null, null, null, $this->query_args);
        $data = $this->fetch_data($q, true, true);
        $this->crud_response($data);
    }


    /**
     * GET request for list of multiple related resources
     *
     * @return radix (list) and metaData
     * @param string  $uuid    identifier of base model
     * @param string  $rel_uri uri of related model
     */
    public function crud_GET_list_related($uuid, $rel_uri) {
        $this->base_uuid = $uuid;
        $this->rel_uri = $rel_uri;

        $q = $this->build_query($uuid, $rel_uri, null, $this->query_args);
        $data = $this->fetch_data($q, true, true);
        $this->crud_response($data);
    }


    /**
     * POST (create) a resource
     *
     * @return radix (single)
     */
    public function crud_POST() {
        $post_data = $this->_decode_input();
        $new = Doctrine::getTable($this->base_model_name)->create();

        $this->_update_record($new, $post_data);

        // apply authz rules *after* update_record in case $new
        // requires actual data to decide about write-ability.
        $this_user = $this->airuser->get_user();
        if (!$new->user_may_write($this_user)) {
            $this->show_403();
        }

        try {
            $new->save();

            // re-fetch the new record, using master connection
            $uuid = $new[air2_get_model_uuid_col($this->base_model_name)];
            $q = $this->build_query($uuid);
            $q->setConnection(AIR2_DBManager::get_master_connection());
            $data = $this->fetch_data($q, false); //no metadata
            $this->crud_response($data);
        }
        catch (Exception $err) {
            $this->success = false;
            $this->message = $err->getMessage();
            $this->crud_response();
        }


    }


    /**
     * POST (create) a related resource
     *
     * @return radix (single)
     * @param string  $uuid    identifier of base model
     * @param string  $rel_uri uri of related model
     */
    public function crud_POST_related($uuid, $rel_uri) {
        $this->base_uuid = $uuid;
        $this->rel_uri = $rel_uri;

        // make sure this base and uri are valid
        $tmpq = $this->build_query($uuid);

        // create new
        $post_data = $this->_decode_input();
        $rel_name = $this->exposed_relations[$rel_uri];
        $rel = Doctrine::getTable($this->base_model_name)->getRelation($rel_name);
        $new = $rel->getTable()->create();
        $this->_update_record($new, $post_data);

        // set the related key
        $base = $this->_find_base();
        $foreign_key = $rel->getForeignColumnName();
        $local_key = $rel->getLocalColumnName();
        $new->$foreign_key = $base->$local_key;

        // final authz check
        $this_user = $this->airuser->get_user();
        if (!$new->user_may_write($this_user)) {
            $this->show_403();
        }

        try {
            $new->save();

            // find the record's uuid (a bit complex)
            $uuid_col = air2_get_model_uuid_col($rel->getClass());
            if (strpos($uuid_col, ':')) {
                $parts = explode(':', $uuid_col);
                $rel_uuid = $new[$parts[0]][$parts[1]];
            }
            else {
                $rel_uuid = $new[$uuid_col];
            }

            // re-fetch the new record, using master connection
            $q = $this->build_query($uuid, $rel_uri, $rel_uuid);
            $q->setConnection(AIR2_DBManager::get_master_connection());
            $data = $this->fetch_data($q, false); //no metadata
            $this->crud_response($data);
        }
        catch (Exception $err) {
            $this->success = false;
            $this->message = $err->getMessage();
            $this->crud_response();
        }
    }


    /**
     * PUT (update) a resource
     *
     * @return radix (single)
     * @param string  $uuid identifier of base model
     */
    public function crud_PUT($uuid) {
        $this->base_uuid = $uuid;

        $put_data = $this->_decode_input();

        $q = $this->build_query($uuid);
        $record = $q->fetchOne();
        if (!$record) {
            $this->show_404();
        }
        $this_user = $this->airuser->get_user();
        if (!$record->user_may_write($this_user)) {
            $this->show_403();
        }

        $this->_update_record($record, $put_data);

        try {
            $record->save();

            //re-fetch, so we get all the default related fields
            $q->setConnection(AIR2_DBManager::get_master_connection());
            $data = $this->fetch_data($q, false);
            $this->crud_response($data);
        }
        catch (Exception $err) {
            $this->success = false;
            $this->message = $err->getMessage();
            $this->crud_response();
        }
    }


    /**
     * PUT (update) a related resource
     *
     * @return radix (single)
     * @param string  $uuid     identifier of base model
     * @param string  $rel_uri  uri of related model
     * @param string  $rel_uuid identifier of related model
     */
    public function crud_PUT_related($uuid, $rel_uri, $rel_uuid) {
        $this->base_uuid = $uuid;
        $this->rel_uri = $rel_uri;
        $this->rel_uuid = $rel_uuid;

        $put_data = $this->_decode_input();

        $q = $this->build_query($uuid, $rel_uri, $rel_uuid);
        $record = $q->fetchOne();
        if (!$record) {
            $this->show_404();
        }
        $this_user = $this->airuser->get_user();
        if (!$record->user_may_write($this_user)) {
            $this->show_403();
        }

        $this->_update_record($record, $put_data);

        try {
            $record->save();

            // re-fetch the new record
            $q->setConnection(AIR2_DBManager::get_master_connection());
            $data = $this->fetch_data($q, false);
            $this->crud_response($data);
        }
        catch (Exception $err) {
            $this->success = false;
            $this->message = $err->getMessage();
            $this->crud_response();
        }
    }


    /**
     * DELETE (delete) a resource
     *
     * @return radix (single, only contains resource uuid)
     * @param string  $uuid identifier of base model
     */
    public function crud_DELETE($uuid) {
        $this->base_uuid = $uuid;

        $q = $this->build_query($uuid);
        $record = $q->fetchOne();
        if (!$record) {
            $this->show_404();
        }
        $this_user = $this->airuser->get_user();
        if (!$record->user_may_delete($this_user)) {
            $this->show_403();
        }

        // TODO pass writeable db handle into delete()
        try {
            $this->success = $record->delete();

            // return deleted uuid
            $uuid_col = air2_get_model_uuid_col($this->base_model_name);
            $this->crud_response(array(
                    'radix' => array($uuid_col => $uuid),
                ));
        }
        catch (Exception $err) {
            $this->success = false;
            $this->message = $err->getMessage();
            $this->crud_response();
        }
    }


    /**
     * DELETE (delete) a related resource
     *
     * @return radix (single, only contains related resource uuid)
     * @param string  $uuid     identifier of base model
     * @param string  $rel_uri  uri of related model
     * @param string  $rel_uuid identifier of related model
     */
    public function crud_DELETE_related($uuid, $rel_uri, $rel_uuid) {
        $this->base_uuid = $uuid;
        $this->rel_uri = $rel_uri;
        $this->rel_uuid = $rel_uuid;

        $q = $this->build_query($uuid, $rel_uri, $rel_uuid);
        $record = $q->fetchOne();
        if (!$record) {
            $this->show_404();
        }
        $this_user = $this->airuser->get_user();
        if (!$record->user_may_delete($this_user)) {
            $this->show_403();
        }

        // TODO pass writeable db handle into delete() ??
        try {
            $this->success = $record->delete();

            // return deleted uuid
            $alias = $q->getRootAlias(); // IMPORTANT! GET THIS BEFORE ->getRoot()
            $uuid_col = air2_get_model_uuid_col($q->getRoot()->getComponentName());
            $this->crud_response(array(
                    'radix' => array($uuid_col => $rel_uuid),
                ));
        }
        catch (Exception $err) {
            $this->success = false;
            $this->message = $err->getMessage();
            $this->crud_response();
        }
    }


    //****************************************************************************//
    //  RESPONSE FUNCTIONS                                                        //
    //  called by API functions (above) to get data and return it                 //
    //****************************************************************************//
    /**
     * Send a response (return 200), loading html if requested, and adding the
     * success and message parameters.
     *
     * @param associative-array $data (optional)
     */
    public function crud_response($data=array()) {
        // add success and message
        $data['success'] = $this->success;
        if ($this->message) $data['message'] = $this->message;

        // add authz, if fetching a single base resource
        if ($this->base_uuid && !$this->rel_uri && $this->request_method != 'DELETE') {
            $rec = $this->_find_base();
            $this_user = $this->airuser->get_user();
            try {
                $data['authz'] = array(
                    'may_read' => $rec->user_may_read($this_user),
                    'may_write' => $rec->user_may_write($this_user),
                    'may_manage' => $rec->user_may_manage($this_user),
                );
            }
            catch (Exception $e) {
                // TODO: get rid of this try/catch, which is needed because some
                // base items haven't implemented user_may_read/write/manage.
            }
        }

        // load html view, if necessary
        if ($this->response_view == 'html') {
            $this->_log_recent_view();
            $body = $this->get_html_body($data);
            $head = $this->get_html_head();
            $this->response(array('body' => $body, 'head' => $head));
        }
        else {
            $this->response($data);
        }
        exit(0); // We never print anything after sending a response
    }


    /**
     * Respond with an API error message (return 200), and exit
     *
     * @param string  $msg
     */
    public function crud_error($msg) {
        // add success and message
        $this->success = false;
        $this->message = $msg;
        $this->crud_response(); //exits
    }


    /**
     * Get any extra items for the head section of the html view.  This can
     * include $head['title'], $head['js'], $head['css'], and $head['misc'].
     * Note that all AIR2 js/css files are included by default, so the only
     * things you might need to include would be external files.
     *
     * @param array   $head (optional) header data
     * @return associative-array head section data
     */
    public function get_html_head($head=array()) {
        return $head;
    }


    /**
     * Get data for the body section of an html request.  If a view is not
     * specified, it will load the default base view name in app/views/crud/.
     *
     * @param associative-array $data      data to assist in rendering the body view
     * @param string  $view_name (optional) the view to load (in app/views/)
     * @return $string the html body section, as rendered by the view
     */
    public function get_html_body($data, $view_name=null) {
        if (!$view_name) {
            if (!$this->rel_uri && file_exists(APPPATH.'views/crud/'.$this->base_view_name.EXT)) {
                $view_name = 'crud/'.$this->base_view_name;
            }
            else {
                $this->show_415();
            }
        }

        // check for AIR2 generic data
        if (!isset($data['home_url'])) $data['home_url'] = $this->uri_for('');
        if (!isset($data['logout_url'])) $data['logout_url'] = $this->uri_for('logout');
        if (!isset($data['user_name'])) $data['user_name'] = $this->airuser->get_username();

        $body = $this->load->view($view_name, $data, true);
        return $body;
    }


    /**
     * Execute a doctrine query, using the AirQuery library
     *
     * @param AIR2_Query $q           (reference) query to execute
     * @param boolean $add_meta    (optional) (default: true) generate metadata for results
     * @param boolean $return_list (optional) (default: false) returns a list in radix,
     * @return associative-array the fetched data
     */
    public function fetch_data(&$q, $add_meta=true, $return_list=false) {
        $params = array(
            'add_meta'    => $add_meta,
            'return_list' => $return_list,
            'blacklist'   => array_merge($this->column_blacklist, $this->global_blacklist),
            'whitelist'   => $this->column_whitelist,
            'extra'       => $this->extra_field_defs,
            'sort'        => $this->airqargs->sort_applied,
        );

        // determine if anything needs to be flattened
        if ($this->rel_uri) {
            $rel_name = $this->exposed_relations[$this->rel_uri];
            if (isset($this->flatten_mapping[$rel_name])) {
                $params['flatten'] = $this->flatten_mapping[$rel_name];
            }
        }

        $data = $this->airquery->execute_query($q, $params);

        if (!$data) $this->show_404();
        return $data;
    }


    /**
     * Decodes input for http POST and PUT requests, throwing the appropriate
     * errors based on it's sanity.  400 will be thrown on both a bad json
     * decode, and if pname isn't set and abort=true.
     *
     * @param string  $pname (optional) the name of a param to decode
     * @param bool    $abort (optional) abort if param isn't set
     * @return associative-array the decoded input, or null if none existed
     */
    public function _decode_input($pname='radix', $abort=true) {

        // PHP does not put request params into $_POST
        // on a PUT request, so CI can't get to them via
        // its normal ->input->post() method.
        // Thankfully,
        // http://www.lornajane.net/posts/2008/Accessing-Incoming-PUT-Data-from-PHP.

        if ($this->request_method == 'PUT') {
            // cache the PUT input, since it can only be read once
            if (!$this->cached_put) {
                $put_input = file_get_contents('php://input');
                parse_str($put_input, $this->cached_put);
            }

            if ($this->cached_put && isset($this->cached_put[$pname])) {
                $post_data = $this->cached_put[$pname];
            }

            // if magic quotes are on, strip them out!
            if (isset($post_data) && get_magic_quotes_gpc()) {
                $post_data = stripslashes($post_data);
            }
        }
        else {
            $post_data = $this->input->post($pname);
        }

        if (!isset($post_data)) {
            if ($abort) {
                header('Content-type: text/plain', true, 400);
                echo "Error: Bad record format in key '$pname'";
                exit(0);
            }
            else {
                return null;
            }
        }

        $decoded_data = null;
        try {
            $decoded_data = json_decode($post_data, true);
        }
        catch ( Exception $err ) {
            header('Content-type: text/plain', true, 400);
            echo "Json decode error: Bad record format in key '$pname'";
            exit(0);
        }

        return $decoded_data;
    }


    /**
     * Modify a record with a set of data.  Called by PUT and POST requests to
     * modify a record, given some data passed in by the request.  Where more
     * complex data checking/setting is involved, the child controller should
     * override this method.  (WITHOUT calling back to parent::_update...)
     *
     * @param Doctrine_Record $record (reference) the record object to modify
     * @param associative-array $data   the radix data to update the record with
     */
    protected function _update_record(&$record, $data) {
        if ($data && air2_is_assoc_array($data)) {
            foreach ($data as $key => $val) {
                if ($record->getTable()->hasColumn($key)) {
                    $record->$key = $val;
                }
                else {
                    $this->message .= " -- unknown column $key on "
                        . $record->getTable()->getClassnameToReturn();
                }
            }
        }
    }


    /**
     * Helper function for getting the base object from its uuid.  This is
     * useful if you want to get a record object instead of just adding a where
     * clause to a query.  The record is also cached for future use.
     *
     * Returns 404 if the base doesn't exist
     *
     * @return Doctrine_Record the base object
     */
    protected function _find_base() {
        if (!$this->base_record_cache) {
            $uuid_col = air2_get_model_uuid_col($this->base_model_name);
            $base_table = Doctrine::getTable($this->base_model_name);
            $this->base_record_cache = $base_table->findOneBy($uuid_col, $this->base_uuid);
            if (!$this->base_record_cache) {
                $this->show_404(); // base doesn't exist
            }

            // check user authz on the base record
            $this_user = $this->airuser->get_user();
            if ($this->request_method == 'GET') {
                if (!$this->base_record_cache->user_may_read($this_user)) {
                    $this->show_403();
                }
            }
            else {
                // I don't think we should be running authz on the base object
                // ... it breaks things when someone can write to related but
                // not to the base objects.
                //if (!$this->base_record_cache->user_may_write($this_user)) {
                //    $this->show_403();
                //}
            }
        }
        return $this->base_record_cache;
    }


    /**
     * Record HTML visits to certain views, for display in the HTML header.
     */
    protected function _log_recent_view() {
        $view = $this->base_view_name;

        if ($this->base_uuid && in_array($view, $this->recent_views)) {
            $this_user = $this->airuser->get_user();
            $recent = $this_user->get_pref('recent');

            // set the list, if DNE
            if (!isset($recent[$view])) {
                $recent[$view] = array();
            }

            $data = array();
            $rec = $this->_find_base();
            if ($view == 'project') {
                $data['prj_uuid'] = $rec->prj_uuid;
                $data['prj_name'] = $rec->prj_name;
                $data['prj_display_name'] = $rec->prj_display_name;
            }
            elseif ($view == 'source') {
                $data['src_uuid'] = $rec->src_uuid;
                $data['src_first_name'] = $rec->src_first_name;
                $data['src_last_name'] = $rec->src_last_name;
                $data['src_username'] = $rec->src_username;
            }
            elseif ($view == 'inquiry') {
                $data['inq_uuid'] = $rec->inq_uuid;
                $data['inq_title'] = $rec->inq_title;
                $data['inq_ext_title'] = $rec->inq_ext_title;
            }
            elseif ($view == 'submission') {
                $data['srs_id'] = $rec->srs_id;
                $data['inq_ext_title'] = $rec->Inquiry->inq_ext_title;
                $data['src_first_name'] = $rec->Source->src_first_name;
                $data['src_last_name'] = $rec->Source->src_last_name;
                $data['src_username'] = $rec->Source->src_username;
            }

            // avoid copies of items
            $idx = array_search($data, $recent[$view]);
            if ($idx !== false) {
                array_splice($recent[$view], $idx, 1);
            }

            // push on front of recent
            array_unshift($recent[$view], $data);
            if (count($recent[$view]) > $this->recent_max) {
                array_pop($recent[$view]);
            }
            $this_user->set_pref('recent', $recent);
            $this_user->save();
        }
    }


    //****************************************************************************//
    //  QUERY BUILDING                                                            //
    //****************************************************************************//
    /**
     * Builds a query for a resource, specifying between 0 and 3 identifiers.
     * Also loads default related resources, userstamps, and adds query and
     * paging parameters.
     *
     * @param string  $uuid     (optional) uuid of the base resource
     * @param string  $rel_uri  (optional) uri of the related resource
     * @param string  $rel_uuid (optional) uuid of the related resource
     * @param array   $params   (optional) array of query params (usually from GET)
     * @return AIR2_Query
     */
    public function build_query($uuid=null, $rel_uri=null, $rel_uuid=null, $params=null) {
        // reset our class-wide vars while building this query
        $old_base_uuid = $this->base_uuid; $old_rel_uri = $this->rel_uri; $old_rel_uuid = $this->rel_uuid;
        $this->base_uuid = $uuid; $this->rel_uri = $rel_uri; $this->rel_uuid = $rel_uuid;

        $q = AIR2_Query::create();
        if (!$uuid && !$rel_uri) { // list base
            $q->from($this->base_model_name.' a');
        }
        elseif (!$uuid && $rel_uri) { // list an unrelated model
            if ( !isset($this->exposed_relations[$rel_uri]) ) {
                $this->show_404(); // relation doesn't exist
            }
            else {
                $rel_name = $this->exposed_relations[$rel_uri];
                $q = AIR2_Query::create()->from("$rel_name a");
            }
        }
        elseif ($uuid && !$rel_uri) { // get one base
            $q->from($this->base_model_name.' a');
            $this->_add_query_uuid($q, $uuid);
        }
        elseif ($uuid && $rel_uri && !$rel_uuid) { // list related
            // MAKE SURE the base object exists!
            $this->_find_base();

            $r = null;
            if (isset($this->exposed_relations[$rel_uri]))
                $r = $this->exposed_relations[$rel_uri];

            if (is_string($r)) {
                $rel_name = $this->exposed_relations[$rel_uri];
                $tbl = Doctrine::getTable($this->base_model_name);
                if ($tbl->hasRelation($rel_name)) {
                    $rel = $tbl->getRelation($rel_name);
                    $q = AIR2_Query::create()->from($rel->getClass().' a');
                    $this->_add_query_related_uuid($q, $uuid, $rel);
                }
                else { // from rel_name object --- up to subclass to add where
                    $q = AIR2_Query::create()->from("$rel_name a");
                }
            }
            elseif ($r === true) {
                // NO query will be created... the child class must load data
            }
            else {
                $this->show_404(); // relation can't exist
            }
        }
        elseif ($uuid && $rel_uri && $rel_uuid) { // get one related
            if ( !isset($this->exposed_relations[$rel_uri]) ) {
                $this->show_404(); // relation can't exist
            }
            $rel_name = $this->exposed_relations[$rel_uri];
            $tbl = Doctrine::getTable($this->base_model_name);
            if ($tbl->hasRelation($rel_name)) {
                $rel = $tbl->getRelation($rel_name);
                $q = AIR2_Query::create()->from($rel->getClass().' a');
                $this->_add_query_related_uuid($q, $uuid, $rel);
                $this->_add_query_uuid($q, $rel_uuid);
            }
            else { // from rel_name object --- up to subclass to add where
                $q = AIR2_Query::create()->from("$rel_name a");
            }
        }
        $this->_add_query_userstamps($q);
        $this->_add_query_load_related($q);

        if ($params) {
            $this->airqargs->process_args($q, $params);
        }

        // add authz
        $this->airqauthz->add_authz($q);

        // restore the old values
        $this->base_uuid = $old_base_uuid; $this->rel_uri = $old_rel_uri; $this->rel_uuid = $old_rel_uuid;

        return $q;
    }


    /**
     * Adds a "where" clause to the query on the root object (the "from")
     *
     * @param AIR2_Query $q    (reference) query to modify
     * @param string  $uuid identifier of the root object of this query
     */
    protected function _add_query_uuid(&$q, $uuid) {
        $alias = $q->getRootAlias(); // IMPORTANT! GET THIS BEFORE ->getRoot()
        $tbl = $q->getRoot();
        $uuid_col = air2_get_model_uuid_col($tbl->getComponentName());

        // check for a foreign UUID
        $parts = explode(':', $uuid_col);
        if (count($parts) == 2) {
            $rel_alias = $parts[0];
            $uuid_col = $parts[1];

            // make sure we've joined it
            $matches = preg_grep("/^[\w]+\.$rel_alias/", $q->getDqlPart('from'));
            if (count($matches) == 0) {
                $q->leftJoin("a.$rel_alias ara");
                $q->getSqlQuery(); // re-trigger parser
                $alias = 'ara';
            }
            else {
                $rels = explode(' ', $matches[0]);
                $alias = (count($rels) == 2) ? $rels[1] : null;
            }
        }

        // add the where restriction
        if ($alias) {
            $q->addWhere("$alias.$uuid_col = ?", $uuid);
        }
        else {
            $q->addWhere("$uuid_col = ?", $uuid);
        }
    }


    /**
     * Adds a "where" clause to the query to restrict a related object (at the
     * root of the query) to have a base object (the main object represented
     * by the controller) with a certain base_uuid.
     * object, expressed as a uri
     *
     * @param AIR2_Query $q         (reference) query to modify
     * @param string  $base_uuid identifier of the base object
     * @param string  $relation  the root query object's relation to the base
     */
    protected function _add_query_related_uuid(&$q, $base_uuid, $relation) {
        $base = $this->_find_base();
        $alias = $q->getRootAlias();

        // get relation info
        $foreign_key = $relation->getForeignColumnName();
        $local_key = $relation->getLocalColumnName();

        // alter the query
        if ($q->getRoot()->hasColumn($foreign_key)) {
            $q->addWhere("$alias.$foreign_key = ?", $base->$local_key);
        }
        else {
            $q->addWhere("$foreign_key = ?", $base->$local_key);
        }
    }


    /**
     * Adds CreUser and UpdUser leftJoins to a query
     *
     * @param AIR2_Query $q      (reference) query to modify
     * @param boolean $deeply (optional) recursively scans any joined components
     */
    protected function _add_query_userstamps(&$q, $deeply=false) {
        // get the components from the query object
        $alias = $q->getRootAlias();
        $comps = $q->getQueryComponents();

        foreach ($comps as $name => $info) {
            $table = $info['table'];

            // root won't have parent set
            if (!isset($info['parent']) || $deeply) {
                // must have defined alias ('a' as opposed to 'a.related')
                if ($table->hasRelation('CreUser') && !strpos('.', $name)) {
                    $q->leftJoin("$name.CreUser ${name}creuser");
                }
                if ($table->hasRelation('UpdUser') && !strpos('.', $name)) {
                    $q->leftJoin("$name.UpdUser ${name}upduser");
                }
            }
        }
    }


    /**
     * Adds leftJoins to the query based on the relation names defined in
     * $this->autoload_relations.  Normally, this method is overriden to provide
     * controllers a custom means of altering the query before execution.  When
     * overriding this method, be sure to end with "return parent::_add_..."
     *
     * @param AIR2_Query $q      (reference) query to modify
     * @param boolean $deeply (optional) recursively scans any joined components
     */
    protected function _add_query_load_related(&$q, $deeply=false) {
        // get the components from the query object
        $alias = $q->getRootAlias();
        $comps = $q->getQueryComponents();

        foreach ($comps as $name => $info) {
            $table = $info['table'];

            // root won't have parent set
            if (!isset($info['parent']) || $deeply) {
                foreach ($this->autoload_relations as $rel_uri) {
                    $rel_name = $this->exposed_relations[$rel_uri];
                    if ($table->hasRelation($rel_name) && !strpos('.', $name)) {
                        $q->leftJoin("$name.$rel_name");
                    }
                }
            }
        }
    }


    //****************************************************************************//
    //  TAGGING FUNCTIONS                                                         //
    //  for base resources that are "taggable"                                    //
    //****************************************************************************//
    /**
     * GET list of tags associated with a taggable record object. To allow an
     * HTML view of this tags list, override this method in the subclass and
     * call parent::crud_tags_list only when response type != HTML
     *
     * @param string  $uuid unique identifier of the taggable object
     */
    public function crud_tags_list($uuid) {
        $this->base_uuid = $uuid;
        $record = $this->_find_base();
        if (!$this->taggable) {
            $this->show_404(); // exits
        }
        if ($this->response_view == 'html') {
            $this->show_415();
        }

        $data = $this->airtag->fetch($record, $this->paging_args);
        $this->crud_response($data);
    }


    /**
     * POST (add) a tag on this base resource.  Will check if the tag already
     * exists, and create new tag_master records, if necessary.
     *
     * @param string  $uuid identifier of the base resource
     */
    public function crud_tags_add($uuid) {
        $this->base_uuid = $uuid;
        $record = $this->_find_base();
        $this_user = $this->airuser->get_user();
        if (!$this->taggable) {
            $this->show_404(); // exits
        }
        if (!$record->user_may_write($this_user)) {
            $this->show_403();
        }

        $post_data = $this->_decode_input();
        try {
            $data = $this->airtag->add($record, $post_data);
        }
        catch (Exception $err) {
            $this->response(
                array('success' => false, 'message' => $err->getMessage())
            );
            return;
        }
        $this->crud_response($data);
    }


    /**
     * DELETE (remove) a tag on this base resource.  If this was the only usage
     * of the tag, it is removed from tag_master.
     *
     * @param string  $uuid     identifier of the base resource
     * @param string  $tag_uuid identifier of the tag
     */
    public function crud_tags_remove($uuid, $tag_uuid) {
        $this->base_uuid = $uuid;
        $record = $this->_find_base();
        $this_user = $this->airuser->get_user();
        if (!$this->taggable) {
            $this->show_404(); // exits
        }
        if (!$record->user_may_write($this_user)) {
            $this->show_403();
        }

        try {
            $success = $this->airtag->remove($record, $tag_uuid);
        }
        catch (Exception $err) {
            $this->response(
                array('success' => false, 'message' => $err->getMessage())
            );
            return;
        }

        // return the removed uuid
        $this->crud_response(array('radix' => array('tm_id' => $tag_uuid)));
    }


}
